Перейти к основному содержимому

5.09. Рекомендации по разработке на Kotlin

Разработчику Архитектору

Рекомендации по разработке на Kotlin

Введение в культуру кода Kotlin

Рекомендации по разработке формируют основу профессиональной практики. Они обеспечивают согласованность кодовой базы, упрощают совместную работу и снижают стоимость сопровождения. Kotlin как язык проектировался с учётом современных практик, поэтому многие рекомендации естественным образом вытекают из его синтаксиса и стандартной библиотеки. Следование этим правилам позволяет писать код, который читается как хорошо составленное предложение на естественном языке.

Соглашения об именовании

Основные принципы именования

Все идентификаторы в Kotlin используют осмысленные английские слова без сокращений, за исключением общепринятых аббревиатур вроде id, url, io. Имена передают назначение сущности и её роль в системе. Короткие имена допустимы только в узких контекстах: счётчики циклов (i, j), лямбда-параметры (it), временные переменные в небольших блоках.

Стили написания идентификаторов

СущностьСтильПример
Классы, объекты, перечисления, аннотацииPascalCaseUserProfile, NetworkClient
Функции, свойства, переменныеcamelCasecalculateTotal, isActive, userCount
Константы верхнего уровня и объектовUPPER_SNAKE_CASEMAX_RETRY_COUNT, DEFAULT_TIMEOUT_MS
Пакетыстрочные буквы, без подчёркиванийcom.example.data.repository
Параметры функцийcamelCaseuserId, requestBody

Специфические правила именования

Имена функций начинаются с глагола или глагольной группы: loadUsers, validateInput, transformData. Для булевых свойств и функций используйте префиксы is, has, can, should: isEnabled, hasPermission, canEdit. Избегайте избыточных префиксов вроде m_ или s_ — область видимости и контекст делают их ненужными.

При именовании расширений (extension functions) учитывайте получателя: String.toUri() читается естественно, тогда как String.convertToUri() избыточен. Для операторных функций соблюдайте семантику оператора: plus для объединения, get для доступа по индексу.

Имена файлов совпадают с основным классом в файле или описывают содержимое, если файл содержит несколько классов или только функции верхнего уровня. Файлы с расширениями группируются по типу получателя: StringExtensions.kt, FlowExtensions.kt.

Форматирование кода

Отступы и переносы строк

Используйте четыре пробела для отступов. Табуляция запрещена. Открывающая фигурная скобка размещается в той же строке, что и объявление, за исключением аннотированных классов и интерфейсов:

fun process(data: List<String>): Result {
return data
.filter { it.isNotEmpty() }
.map { it.trim() }
.let { transform(it) }
}

class UserRepository @Inject constructor(
private val dataSource: DataSource,
private val cache: Cache
) : Repository {
// тело класса
}

При переносе аргументов функции или конструктора каждый параметр размещается на новой строке с выравниванием:

fun createUser(
firstName: String,
lastName: String,
email: String,
role: UserRole = UserRole.USER
): User {
// реализация
}

Пробелы и операторы

Разделяйте операторы пробелами: a + b, x > threshold, list.map { it * 2 }. Не ставьте пробелы внутри скобок: function(arg), а не function( arg ). После запятых и двоеточений в типах добавляйте пробел: List<String>, val name: String.

Для длинных цепочек вызовов каждый метод начинается с новой строки с точкой в начале:

val result = dataSource
.queryUsers()
.filter { it.isActive }
.sortedBy { it.registrationDate }
.map { UserView(it) }
.toList()

Пустые строки

Разделяйте логические блоки пустыми строками:

  • Между свойствами и функциями в классе
  • Между разными группами свойств (публичные, приватные)
  • Перед и после вложенных классов и объектов
  • Между операторами в сложных функциях

Не добавляйте пустые строки после открывающей скобки или перед закрывающей. Избегайте множественных пустых строк подряд — одна строка достаточна для визуального разделения.

Структура проекта и организация файлов

Стандартная структура модуля

src/
├── main/
│ ├── kotlin/
│ │ └── com/
│ │ └── example/
│ │ ├── app/ # точка входа, инициализация
│ │ ├── domain/ # бизнес-логика, сущности
│ │ ├── data/ # источники данных, репозитории
│ │ ├── ui/ # представление, экраны
│ │ └── di/ # конфигурация зависимостей
│ └── resources/
└── test/
└── kotlin/
└── com/
└── example/
├── domain/
├── data/
└── ui/

Принципы организации пакетов

Пакеты группируются по функциональной принадлежности, а не по типу артефакта. Предпочитайте feature.auth вместо разделения на models, views, controllers. Внутри функционального модуля допустимо разделение по слоям: auth.model, auth.repository, auth.viewmodel.

Каждый файл содержит одну основную сущность плюс связанные вспомогательные элементы. Функции расширения группируются в отдельные файлы по типу получателя. Файлы с утилитами сводятся к минимуму — предпочтительнее использовать расширения или внедрение зависимостей.

Проектирование классов и типов

Принцип единственной ответственности

Каждый класс решает одну задачу. Класс с именем, содержащим союз «и» (UserAndOrderManager), сигнализирует о нарушении этого принципа. Разделяйте такие классы на несколько узкоспециализированных компонентов.

Используйте специализированные типы Kotlin для разных сценариев:

  • data class для хранения данных с автоматической реализацией equals, hashCode, toString
  • sealed class или sealed interface для ограниченных иерархий типов
  • value class для обёрток над примитивными типами без накладных расходов
  • object для синглтонов и утилит с внутренним состоянием

Свойства вместо геттеров и сеттеров

Предпочитайте свойства полям с ручными геттерами и сеттерами. Используйте делегаты свойств для стандартных паттернов:

class ViewModel {
private val _users = MutableLiveData<List<User>>()
val users: LiveData<List<User>> = _users

private var cachedData by lazy { loadData() }
private var count by Delegates.observable(0) { _, old, new ->
log("Count changed from $old to $new")
}
}

Для вычисляемых свойств без внутреннего состояния используйте val с кастомным геттером. Избегайте изменяемых свойств (var) без веской причины — неизменяемость упрощает рассуждение о коде.

Конструкторы и инициализация

Основной конструктор размещается в заголовке класса. Для сложной инициализации используйте вторичные конструкторы или фабричные функции верхнего уровня:

class User private constructor(
val id: UserId,
val name: String,
val email: Email
) {
companion object {
fun create(id: String, name: String, email: String): Result<User, Error> {
return when {
name.isBlank() -> Error.InvalidName
!email.contains('@') -> Error.InvalidEmail
else -> Success(User(UserId(id), name, Email(email)))
}
}
}
}

Инициализация объекта через apply или also предпочтительнее последовательного вызова сеттеров:

val request = HttpRequest.Builder()
.url("https://api.example.com")
.method(HttpMethod.POST)
.header("Content-Type", "application/json")
.body(jsonData)
.build()

Работа с функциями

Чистые функции и побочные эффекты

Чистые функции не изменяют внешнее состояние и возвращают один и тот же результат для одинаковых аргументов. Такие функции легко тестируются и комбинируются. Помечайте функции с побочными эффектами комментариями или используйте специальные типы-обёртки для явного указания эффектов.

Функции с побочными эффектами именуются глаголами действия: saveUser(), sendNotification(), logEvent(). Чистые функции могут использовать существительные или прилагательные: calculateTotal(), isValidEmail().

Параметры и аргументы по умолчанию

Используйте параметры по умолчанию вместо перегрузки функций:

fun formatCurrency(
amount: BigDecimal,
currency: Currency = Currency.USD,
locale: Locale = Locale.US,
showSymbol: Boolean = true
): String { /* реализация */ }

Для функций с множеством параметров применяйте именованные аргументы при вызове, особенно когда значения по умолчанию пропускаются:

formatCurrency(
amount = BigDecimal("1234.56"),
locale = Locale.FRANCE,
showSymbol = false
)

Функции высшего порядка и лямбды

Передавайте функции как параметры для расширения поведения без наследования. Для лямбд с одним параметром используйте it. Для нескольких параметров или сложной логики объявляйте именованные параметры:

list.filter { it.isActive }
.map { user -> UserSummary(user.id, user.name) }

data.retryOnFailure(times = 3) { attempt ->
log("Attempt $attempt")
fetchData()
}

Размещайте лямбды после закрывающей скобки, если это последний параметр функции. Для коротких лямбд допустим однострочный формат. Длинные лямбды оформляются как обычные блоки кода с отступами.

Обработка ошибок

Использование исключений

Исключения применяются для действительно исключительных ситуаций, нарушающих нормальный поток выполнения. Не используйте исключения для управления бизнес-логикой. Проверяемые исключения отсутствуют в Kotlin — вместо них применяйте типы Result или специализированные обёртки.

Для операций, которые могут завершиться неудачей, предоставляйте две версии функции:

  • Бросающую исключение: fun getUser(id: UserId): User
  • Возвращающую Result: fun getUserOrNull(id: UserId): Result<User, Error>

Null safety как основа надёжности

Проектируйте API без использования null там, где это возможно. Используйте Optional-подобные типы (Result, Either) или коллекции для представления отсутствия значения. Когда null неизбежен, ограничивайте его область видимости и проверяйте как можно раньше.

Предпочитайте безопасные операторы и элвис-оператор явным проверкам:

val name = user?.profile?.name ?: "Anonymous"
val firstChar = name.firstOrNull() ?: 'A'

Избегайте оператора !! за исключением ситуаций, когда вы абсолютно уверены в ненулевом значении и готовы к падению приложения при нарушении инварианта.

Работа с коллекциями

Иммутабельность по умолчанию

Используйте неизменяемые коллекции (List, Map, Set) везде, где это возможно. Изменяемые коллекции (MutableList и другие) применяются только внутри реализации компонентов и не возвращаются наружу.

class ShoppingCart {
private val _items = mutableListOf<CartItem>()
val items: List<CartItem> get() = _items.toList()

fun addItem(item: CartItem) {
_items.add(item)
}
}

Цепочки преобразований

Стандартная библиотека Kotlin предоставляет богатый набор функций для работы с коллекциями. Комбинируйте их в цепочки для декларативного описания преобразований:

val activePremiumUsers = users
.filter { it.isActive }
.filter { it.subscription.isPremium }
.sortedByDescending { it.lastLoginDate }
.map { UserPreview(it.id, it.name, it.email) }

Для сложных преобразований выносите логику в отдельные функции с осмысленными именами вместо длинных цепочек в одном месте.

Последовательности для больших объёмов данных

При обработке больших коллекций используйте Sequence для ленивых вычислений:

val result = largeDataSet.asSequence()
.filter { it.meetsCriteria() }
.map { transform(it) }
.firstOrNull { it.isTarget() }

Последовательности избегают промежуточных аллокаций, характерных для операций над списками. Преобразуйте последовательность в коллекцию только в конце цепочки, когда требуется материализованный результат.

Асинхронное программирование с корутинами

Структура корутин

Каждая корутина должна иметь свой CoroutineScope с явно определённым жизненным циклом. Используйте viewModelScope в Android ViewModel, lifecycleScope в компонентах с жизненным циклом, или создавайте собственные скоупы с SupervisorJob для независимого завершения дочерних корутин.

class UserRepository(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)

fun loadUsers(): Job = scope.launch {
val users = withContext(ioDispatcher) {
api.fetchUsers()
}
cache.save(users)
}

fun close() {
scope.cancel()
}
}

Обработка ошибок в корутинах

Оборачивайте код в корутинах в try/catch или используйте runCatching. Для обработки ошибок на уровне всей корутины применяйте обработчики исключений через CoroutineExceptionHandler:

val handler = CoroutineExceptionHandler { _, exception ->
log.error("Coroutine failed", exception)
}

scope.launch(handler) {
// операции, которые могут завершиться ошибкой
}

Избегайте глобального перехвата всех исключений без логирования — это скрывает проблемы и затрудняет отладку.

Отмена корутин

Корутины должны корректно реагировать на отмену. Проверяйте isActive в длительных циклах и используйте приостанавливающие функции, поддерживающие отмену (delay, withTimeout). Для блокирующих операций применяйте withContext(NonCancellable) только когда отмена действительно невозможна или опасна.

Комментирование и документирование

Самодокументируемый код

Структура кода, имена и типы должны передавать основной смысл без комментариев. Комментарии объясняют «почему», а не «что» делает код. Избегайте комментариев, дублирующих код:

// Плохо: комментарий повторяет код
// Увеличиваем счётчик на единицу
counter += 1

// Хорошо: комментарий объясняет причину
// Счётчик увеличивается здесь, потому что операция может быть вызвана
// из нескольких мест, но должна учитываться только один раз
counter += 1

KDoc для публичного API

Все публичные классы, функции и свойства сопровождаются документацией в формате KDoc. Описание начинается с краткого предложения, затем развёрнутое объяснение при необходимости. Для параметров и возвращаемых значений используйте теги @param и @return:

/**
* Загружает профиль пользователя по идентификатору.
*
* Функция обращается к удалённому API и кэширует результат.
* При ошибке сети возвращает данные из кэша, если они доступны.
*
* @param userId уникальный идентификатор пользователя
* @param forceRefresh игнорировать кэш и загрузить свежие данные
* @return профиль пользователя или ошибку загрузки
*/
suspend fun loadUserProfile(
userId: UserId,
forceRefresh: Boolean = false
): Result<UserProfile, LoadError> { /* реализация */ }

Комментарии для временных решений

Временные решения и технический долг помечаются комментариями с указанием причины и срока устранения:

// ВРЕМЕННО: обход ошибки в библиотеке версии 2.3.1
// Убрать после обновления до 2.4.0 (планируется в декабре 2026)
val workaroundValue = rawData.replace("invalid", "valid")

Такие комментарии становятся точками внимания при планировании технического долга.

Практики обеспечения качества

Тестирование

Каждый публичный метод покрывается модульными тестами. Тесты группируются по поведению, а не по методам. Используйте выразительные имена тестовых функций:

@Test
fun `calculate total applies discount for premium users`() {
val cart = ShoppingCart(userType = PREMIUM)
cart.add(Item(price = 100))
cart.add(Item(price = 200))

assertEquals(270, cart.calculateTotal())
}

Для тестирования корутин применяйте runTest из kotlinx-coroutines-test. Мокируйте зависимости через интерфейсы, а не через конкретные классы.

Статический анализ

Проект настраивается с анализаторами:

  • ktlint для форматирования и базовых проверок стиля
  • detekt для обнаружения потенциальных ошибок и нарушений архитектуры
  • kotlinx-serialization для безопасной сериализации

Конфигурация анализаторов сохраняется в репозитории и применяется на этапе сборки. Все предупреждения анализаторов исправляются до коммита — накопление предупреждений снижает их ценность.

Непрерывная интеграция

Каждый коммит проходит проверку в CI:

  1. Сборка проекта
  2. Запуск всех тестов
  3. Проверка стиля кода анализаторами
  4. Генерация отчётов о покрытии тестами
  5. Сборка артефактов для развёртывания

Запрещается мержить изменения, нарушающие сборку или тесты. Красная ветка в репозитории считается критической проблемой, требующей немедленного исправления.

Архитектурные рекомендации

Чистая архитектура и слои

Приложение разделяется на слои с односторонними зависимостями:

  • Слой данных зависит от внешних источников (сеть, база данных)
  • Доменный слой содержит бизнес-логику и не зависит от инфраструктуры
  • Слой представления зависит от домена, но не от деталей реализации данных

Зависимости направлены внутрь — от инфраструктуры к бизнес-логике. Инверсия зависимостей достигается через интерфейсы, определённые в доменном слое и реализованные в слое данных.

Инъекция зависимостей

Конструкторная инъекция — основной способ передачи зависимостей. Избегайте сервис-локатора и статических зависимостей. Для конфигурации используйте фреймворки вроде Koin или Dagger/Hilt, но структурируйте код так, чтобы он оставался тестируемым без фреймворка.

class UserViewModel(
private val userRepository: UserRepository,
private val analytics: AnalyticsService
) : ViewModel() {
// реализация
}

Обработка состояния

Для управления состоянием в интерфейсе используйте однонаправленный поток данных:

  1. Событие от пользователя → действие (Action)
  2. Действие → изменение состояния через редьюсер
  3. Новое состояние → обновление интерфейса

Состояние хранится как неизменяемые объекты. Каждое изменение создаёт новую копию состояния. Для реактивного обновления применяются StateFlow или LiveData.

Инструменты и конфигурация

EditorConfig

Файл .editorconfig в корне проекта обеспечивает единообразное форматирование во всех средах разработки:

root = true

[*.{kt,kts}]
indent_size = 4
indent_style = space
max_line_length = 120
insert_final_newline = true
trim_trailing_whitespace = true

[*.{md,txt}]
max_line_length = off

Версионирование зависимостей

Все версии библиотек централизуются в libs.versions.toml или отдельном конфигурационном файле. Это упрощает обновление и гарантирует согласованность версий в мульти-модульных проектах.

Сборка и зависимости

Скрипты сборки пишутся на Kotlin DSL для лучшей типизации и поддержки. Зависимости группируются по назначению: реализация, тестирование, инструменты разработки. Транзитивные зависимости контролируются через исключения, чтобы избежать конфликтов версий.